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
50 changes: 41 additions & 9 deletions components/TranscriptPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowDown,
ArrowUp,
ArrowUpFromLine,
Eye,
EyeOff,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -480,7 +497,7 @@ export default function TranscriptPanel() {
word={w}
cutOut={cutOutIds.has(w.id)}
active={w.id === activeWordId}
onClick={handleWordClick}
onClick={onWordClick}
/>
</React.Fragment>
);
Expand Down Expand Up @@ -585,7 +602,22 @@ export default function TranscriptPanel() {
</div>
{/* Gradient overlay — must match the transcript panel surface */}
<div className="absolute z-10 pointer-events-none inset-x-0 bottom-0 w-full h-20 bg-gradient-to-t from-white to-transparent dark:from-zinc-900" />
<TranscriptScrollIndicator scrollRef={scrollRef} contentRef={containerRef} />
{showFollowControl && (
<button
type="button"
onClick={resumeFollowPlayhead}
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 shadow-md shadow-zinc-900/10 backdrop-blur-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/95 dark:text-zinc-200 dark:shadow-black/30 dark:hover:bg-zinc-700"
>
{followDirection === "up" ? <ArrowUp size={13} /> : <ArrowDown size={13} />}
Follow playhead
</button>
)}
<TranscriptScrollIndicator
scrollRef={scrollRef}
contentRef={containerRef}
onUserScroll={markUserScrollGesture}
/>
</section>
);
}
6 changes: 5 additions & 1 deletion components/TranscriptScrollIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,12 @@ function topAt(anchors: Anchor[], time: number): number {
export default function TranscriptScrollIndicator({
scrollRef,
contentRef,
onUserScroll,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
/** Fired when the user drags or clicks the rail (not programmatic scroll). */
onUserScroll?: () => void;
}) {
const words = useEditorStore((s) => s.words);
const starts = useMemo(() => {
Expand Down Expand Up @@ -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;
};
Expand All @@ -403,7 +407,7 @@ export default function TranscriptScrollIndicator({
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
[scrollRef, bump]
[scrollRef, bump, onUserScroll]
);

return (
Expand Down
160 changes: 160 additions & 0 deletions hooks/useTranscriptPlayheadFollow.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>;
containerRef: RefObject<HTMLDivElement | null>;
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<FollowDirection>("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,
};
}
Loading